Go言語で簡単なgRPCサーバーを作成
はじめに
この記事はGo言語でgRPCサーバーを作る上で学んだことをまとめています。
サンプルプロジェクトとしてシンプルな単行RPC(Unary RPC)サーバーとそれを呼び出すコマンドラインのクライアントを作成していきます。
※この記事は既にGo言語の開発環境をセットアップ済みで基本的な文法を学習済みの方を想定しています。
動作環境
今回使用した動作環境は以下のとおりです。
- PC : Mac M1(Apple Silicon)チップ
- OS : macOS Big Sir 11.5.2
- Go : 1.17.1
gRPCとは
gRPCはGoogleの作ったRPCのフレームワークです。 以下の3つの利点があります。
- HTTP/2による高速な通信が可能。
- Protocol Buffersによるスキーマファーストの開発。protoファイルというIDLからコードの自動生成が可能。
- 様々なストリーミング方式の通信が可能。
以下の4つの通信方式があります。
- 単行RPC(Unary RPC): 1つのリクエストに対して1つのレスポンスを返却する、シンプルな通信方式。
- サーバーストリーミングRPC: 1つのリクエストに対してサーバー側から複数のレスポンスを返却する通信方式。
- クライアントストリーミングRPC: クライアントがサーバーに複数のリクエストを送る通信方式。サイズの大きいファイルのアップロードなどに利用される。
- 双方向ストリーミングRPC: クライアントとサーバーの双方がデータを送り合う通信方式。チャットなどのリアルタイム処理に利用される。
サンプルプロジェクト
このサンプルプロジェクトはシンプルな単行RPC(Unary RPC)です。
ジャンケンで遊べる簡単なサーバーとそれを呼び出すコマンドラインのクライアントの2つを作成します。
サーバーは、ジャンケンを行うAPIと対戦結果の履歴が取得できるAPIの2つのAPIを提供します。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。
プロジェクト構成
このサンプルプロジェクトの最終的なプロジェクト構成は下記になります。
rock-paper-scissors/ ルートディレクトリ ┣ cmd/ ┃ ┣ api/ ┃ ┃ ┗ main.go ・・・ サーバー側メイン処理 ┃ ┗ cli/ ┃ ┗ main.go ・・・ クライアント側メイン処理 ┣ pb/ ┃ ┣ rock-paper-scissors.pb.go ・・・ protoファイルから自動生成されたリクエスト/レスポンス部分のコード ┃ ┗ rock-paper-scissors_grpc.pb.go ・・・ protoファイルから自動生成されたサービス部分のコード ┣ proto/ ┃ ┗ rock-paper-scissors.proto ・・・ このサンプルプロジェクトのprotoファイル ┣ service/ ┃ ┣ client.go ・・・ gRPCでサーバー側のAPIを呼び出す ┃ ┗ server.go ・・・ サービスの各メソッドを実装しgRPCを処理する ┣ go.mod ┗ go.sum
protoファイル
protoファイルはgRPCのAPI定義を定義するファイルです。
このファイルをもとに、各プログラミング言語用のコードが自動生成されます。
protoファイルは仕様書の役目を果し、自動生成されたコードは静的型付けによりAPIの提供元と呼び出し側の両方の生産性を向上させます。
下記がこのサンプルプロジェクトのprotoファイルです。/proto
フォルダ配下に作成してください。
// protoのバージョンです。 syntax = "proto3"; // メッセージ型などの名前の衝突を避けるためにパッケージ名を指定します。 package game; // コードが自動生成されるディレクトリを指定しています。 option go_package = "pb/"; // 他のパッケージのメッセージ型をインポートできます。 // ここではWell Known Typesと呼ばれるGoogle提供のメッセージ型を使用します。 import "google/protobuf/timestamp.proto"; // APIにおけるサービスを定義 service RockPaperScissorsService { // ジャンケンを行います。 rpc PlayGame (PlayRequest) returns (PlayResponse) {} // 対戦結果の履歴を確認します。 rpc ReportMatchResults (ReportRequest) returns (ReportResponse) {} } // enumでグー、チョキ、パーを定義。 enum HandShapes { HAND_SHAPES_UNKNOWN = 0; ROCK = 1; PAPER = 2; SCISSORS = 3; } // enumで勝敗とあいこを定義 enum Result { RESULT_UNKNOWN = 0; WIN = 1; LOSE = 2; DRAW = 3; } // 型にはスカラー型とメッセージ型の2つがあります。 // スカラー型は数値、文字列、真偽値などがあり、メッセージ型は複数のフィールドを持つことができます。 // 対戦結果のメッセージ型です。 message MatchResult { HandShapes yourHandShapes = 1; HandShapes opponentHandShapes = 2; Result result = 3; google.protobuf.Timestamp create_time = 4; } // 今までの試合数、勝敗と対戦結果の履歴を持つメッセージ型です。 message Report { int32 numberOfGames = 1; int32 numberOfWins = 2; // `repeated`を付けることで配列を表現できます。 repeated MatchResult matchResults = 3; } // PlayGameメソッドのリクエスト用のメッセージ型 message PlayRequest { HandShapes handShapes = 1; } // PlayGameメソッドのレスポンス用のメッセージ型 message PlayResponse { MatchResult matchResult = 1; } // ReportMatchResultsメソッドのリクエスト用のメッセージ型 message ReportRequest {} // ReportMatchResultsメソッドのレスポンス用のメッセージ型 message ReportResponse { Report report = 1; }
その他にprotoファイルに関してはProtocol Buffers Language Guideをご参照ください。
プロジェクトの作成とコード自動生成手順
1. ルートディレクトリ直下で下記コマンドを実行しプロジェクトを作成します。
% mkdir rock-paper-scissors % cd ./rock-paper-scissors % go mod init {Repository FQDN}/{Repository Path}/rock-paper-scissors
2. Go言語のgRPCライブラリを追加します。
% go get -u google.golang.org/grpc
3. protoファイルのコンパイルコマンドをインストールします。
% brew install protobuf
4. Go言語のコード自動生成用プラグインをインストールします。
% go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26 % go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
私はここで少し詰まったので補足します。
Go言語のProtocol Buffers用のAPIはv1とv2で動きが違うようです。※詳しくはProtocol Buffers用 Go言語APIの APIv1 と APIv2 の差異の記事を参照ください。
サービスのコードが自動生成されずに迷いましたが、google.golang.org/grpc/cmd/protoc-gen-go-grpc
をインストールすることで解決しました。
5. proto
フォルダ配下にrock-paper-scissors.proto
を配置後、下記コマンドを実行してください。/pb
配下にコードが自動生成されます。
% protoc --go_out=. --go-grpc_out=require_unimplemented_servers=false:. ./proto/rock-paper-scissors.proto
--go_out
オプションはリクエスト/レスポンス部分のコードの出力先を、--go-grpc_out
オプションはサービス部分のコードの出力先を指定しています。
require_unimplemented_servers=false
はmustEmbedUnimplemented*** method
というメソッドが自動生成されないための指定です。
サーバー側ソースコードの説明
package main import ( "fmt" "log" "net" "google.golang.org/grpc" "google.golang.org/grpc/reflection" "github.com/hoge/rock-paper-scissors/pb" "github.com/hoge/rock-paper-scissors/service" ) func main() { // 起動するポート番号を指定しています。 port := 50051 listenPort, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { log.Fatalf("failed to listen: %v", err) } // gRPCサーバーの生成 server := grpc.NewServer() // 自動生成された関数に、サーバと実際に処理を行うメソッドを実装したハンドラを設定します。 // protoファイルで定義した`RockPaperScissorsService`に対応しています。 pb.RegisterRockPaperScissorsServiceServer(server, service.NewRockPaperScissorsService()) // サーバーリフレクションを有効にしています。 // 有効にすることでシリアライズせずとも後述する`grpc_cli`で動作確認ができるようになります。 reflection.Register(server) // サーバーを起動 server.Serve(listenPort) }
package service import ( "context" "math/rand" "time" "github.com/golang/protobuf/ptypes/timestamp" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/hoge/rock-paper-scissors/pb" "github.com/hoge/rock-paper-scissors/pkg" ) func init() { rand.Seed(time.Now().UnixNano()) } // DBを使わずに対戦結果の履歴を表示できるように構造体にデータを保持しています。 type RockPaperScissorsService struct { numberOfGames int32 numberOfWins int32 matchResults []*pb.MatchResult } // `RockPaperScissorsService`を生成し返却するコンストラクタです。 // `RockPaperScissorsService`は`PlayGame`と`ReportMatchResults`メソッドを実装しています。 func NewRockPaperScissorsService() *RockPaperScissorsService { return &RockPaperScissorsService{ numberOfGames: 0, numberOfWins: 0, matchResults: make([]*pb.MatchResult, 0), } } // 自動生成された`rock-paper-scissors_grpc.pb.go`の // `RockPaperScissorsServiceServer`インターフェースを実装しています。 func (s *RockPaperScissorsService) PlayGame(ctx context.Context, req *pb.PlayRequest) (*pb.PlayResponse, error) { if req.HandShapes == pb.HandShapes_HAND_SHAPES_UNKNOWN { return nil, status.Errorf(codes.InvalidArgument, "Choose Rock, Paper, or Scissors.") } // ランダムに1~3の数値を生成し相手の手とし、`HandShapes`のenumに変換しています。 opponentHandShapes := pkg.EncodeHandShapes(int32(rand.Intn(3) + 1)) // ジャンケンの勝敗を決めています。 var result pb.Result if req.HandShapes == opponentHandShapes { result = pb.Result_DRAW } else if (req.HandShapes.Number()-opponentHandShapes.Number()+3)%3 == 1 { result = pb.Result_WIN } else { result = pb.Result_LOSE } now := time.Now() // 自動生成された型を元に対戦結果を生成 matchResult := &pb.MatchResult{ YourHandShapes: req.HandShapes, OpponentHandShapes: opponentHandShapes, Result: result, CreateTime: ×tamp.Timestamp{ Seconds: now.Unix(), Nanos: int32(now.Nanosecond()), }, } // 試合数を1増やし、プレイヤーが勝利した場合は勝利数も1増やします。 s.numberOfGames = s.numberOfGames + 1 if result == pb.Result_WIN { s.numberOfWins = s.numberOfWins + 1 } s.matchResults = append(s.matchResults, matchResult) // 自動生成されたレスポンス用のコードを使ってレスポンスを作り返却しています。 return &pb.PlayResponse{ MatchResult: matchResult, }, nil } // 自動生成された`rock-paper-scissors_grpc.pb.go`の // `RockPaperScissorsServiceServer`インターフェースを実装しています。 func (s *RockPaperScissorsService) ReportMatchResults(ctx context.Context, req *pb.ReportRequest) (*pb.ReportResponse, error) { // 自動生成されたレスポンス用のコードを使ってレスポンスを作り返却しています。 return &pb.ReportResponse{ Report: &pb.Report{ NumberOfGames: s.numberOfGames, NumberOfWins: s.numberOfWins, MatchResults: s.matchResults, }, }, nil }
package pkg import "github.com/hoge/rock-paper-scissors/pb" func EncodeHandShapes(n int32) pb.HandShapes { switch n { case 1: return pb.HandShapes_ROCK case 2: return pb.HandShapes_PAPER case 3: return pb.HandShapes_SCISSORS default: return pb.HandShapes_HAND_SHAPES_UNKNOWN } }
サーバー動作確認
デバック用のツールgrpc_cli
をインストールします。
% brew tap grpc/grpc % brew install grpc
ここまでのファイルを配置してもらうとルートディレクトリ直下で下記コマンドを実行するとサーバーが起動します。
% go run ./cmd/api
別のターミナルを立ち上げ、下記コマンドでgRPCサーバーが起動しているか確認します。起動に成功しているとサービスのメソッドがサーバーから返却されます。
% grpc_cli ls localhost:50051 game.RockPaperScissorsService PlayGame ReportMatchResults
実際にジャンケンをやってみます。1:グー
を指定してPlayGame
メソッドを呼び出します。
% grpc_cli call localhost:50051 game.RockPaperScissorsService.PlayGame 'handShapes: 1' connecting to localhost:50051 matchResult { yourHandShapes: ROCK opponentHandShapes: SCISSORS result: WIN create_time { seconds: 1632749016 nanos: 862928000 } } Rpc succeeded with OK status
ReportMatchResults
メソッドを呼び出し対戦結果を確認します。
% grpc_cli call localhost:50051 game.RockPaperScissorsService.ReportMatchResults '' connecting to localhost:50051 report { numberOfGames: 1 numberOfWins: 1 matchResults { yourHandShapes: ROCK opponentHandShapes: SCISSORS result: WIN create_time { seconds: 1632749016 nanos: 862928000 } } } Rpc succeeded with OK status
クライアント側ソースコードの説明
次に実際にgRPCのAPIを呼び出すコマンドラインのクライアント側のソースコードを説明します。
package main import ( "bufio" "fmt" "os" "strconv" "github.com/hoge/rock-paper-scissors/service" ) func main() { fmt.Println("start Rock-paper-scissors game.") scanner := bufio.NewScanner(os.Stdin) for { fmt.Println("1: play game") fmt.Println("2: show match results") fmt.Println("3: exit") fmt.Print("please enter >") scanner.Scan() in := scanner.Text() switch in { case "1": fmt.Println("Please enter Rock, Paper, or Scissors.") fmt.Println("1: Rock") fmt.Println("2: Paper") fmt.Println("3: Scissors") fmt.Print("please enter >") scanner.Scan() in = scanner.Text() switch in { case "1", "2", "3": handShapes, _ := strconv.Atoi(in) // この関数内で`PlayGame`メソッドを呼び出します。 service.PlayGame(int32(handShapes)) default: fmt.Println("Invalid command.") continue } continue case "2": fmt.Println("Here are your match results.") // この関数内で`ReportMatchResults`メソッドを呼び出します。 service.ReportMatchResults() continue case "3": fmt.Println("bye.") goto M default: fmt.Println("Invalid command.") continue } } M: }
package service import ( "context" "fmt" "log" "time" "google.golang.org/grpc" "github.com/hoge/rock-paper-scissors/pb" "github.com/hoge/rock-paper-scissors/pkg" ) func PlayGame(handShapes int32) { address := "localhost:50051" conn, err := grpc.Dial( address, grpc.WithInsecure(), grpc.WithBlock(), ) if err != nil { log.Fatal("Connection failed.") return } defer conn.Close() ctx, cancel := context.WithTimeout( context.Background(), time.Second, ) defer cancel() // 自動生成されたコードからgRPCのクライアントとリクエストを生成します。 client := pb.NewRockPaperScissorsServiceClient(conn) playRequest := pb.PlayRequest{ HandShapes: pkg.EncodeHandShapes(handShapes), } // gRPCサーバーの`PlayGame`メソッドを呼び出します。 reply, err := client.PlayGame(ctx, &playRequest) if err != nil { log.Fatal("Request failed.") return } // レスポンスを標準出力に表示します。 marchResult := reply.GetMatchResult() fmt.Println("***********************************") fmt.Printf("Your hand shapes: %s \n", marchResult.YourHandShapes.String()) fmt.Printf("Opponent hand shapes: %s \n", marchResult.OpponentHandShapes.String()) fmt.Printf("Result: %s \n", marchResult.Result.String()) fmt.Println("***********************************") fmt.Println() } func ReportMatchResults() { address := "localhost:50051" conn, err := grpc.Dial( address, grpc.WithInsecure(), grpc.WithBlock(), ) if err != nil { log.Fatal("Connection failed.") return } defer conn.Close() ctx, cancel := context.WithTimeout( context.Background(), time.Second, ) defer cancel() // 自動生成されたコードからgRPCのクライアントとリクエストを生成します。 client := pb.NewRockPaperScissorsServiceClient(conn) reportRequest := pb.ReportRequest{} // gRPCサーバーの`ReportMatchResults`メソッドを呼び出します。 reply, err := client.ReportMatchResults(ctx, &reportRequest) if err != nil { log.Fatal("Request failed.") return } // レスポンスを標準出力に表示します。 report := reply.GetReport() if len(report.MatchResults) == 0 { fmt.Println("***********************************") fmt.Println("There are no match results.") fmt.Println("***********************************") fmt.Println() return } fmt.Println("***********************************") for k, v := range report.MatchResults { fmt.Println(k + 1) fmt.Printf("Your hand shapes: %s \n", v.YourHandShapes.String()) fmt.Printf("Opponent hand shapes: %s \n", v.OpponentHandShapes.String()) fmt.Printf("Result: %s \n", v.Result.String()) fmt.Printf("Datetime of match: %s \n", v.CreateTime.AsTime().In(time.FixedZone("Asia/Tokyo", 9*60*60)).Format(time.ANSIC)) fmt.Println() } fmt.Printf("Number of games: %d \n", reply.GetReport().NumberOfGames) fmt.Printf("Number of wins: %d \n", reply.GetReport().NumberOfWins) fmt.Println("***********************************") fmt.Println() }
その他google.golang.org/grpc
に関してはgRPC-Goをご参照ください。
クライアント動作確認
ルートディレクトリ直下で下記コマンドを実行するとクライアントが起動します。
% go run ./cmd/cli start Rock-paper-scissors game. 1: play game 2: show match results 3: exit
1を入力するとジャンケンが行なえます。
please enter >1 Please enter Rock, Paper, or Scissors. 1: Rock 2: Paper 3: Scissors
ジャンケンの手を選ぶと対戦結果が表示されます。
please enter >1 *********************************** Your hand shapes: ROCK Opponent hand shapes: PAPER Result: LOSE ***********************************
2を入力すると対戦結果の履歴が表示されます。
1: play game 2: show match results 3: exit please enter >2 Here are your match results. *********************************** 1 Your hand shapes: ROCK Opponent hand shapes: PAPER Result: LOSE Datetime of match: Tue Sep 28 02:56:47 2021 2 Your hand shapes: PAPER Opponent hand shapes: ROCK Result: WIN Datetime of match: Tue Sep 28 02:56:52 2021 3 Your hand shapes: SCISSORS Opponent hand shapes: SCISSORS Result: DRAW Datetime of match: Tue Sep 28 02:56:55 2021 Number of games: 3 Number of wins: 1 ***********************************
3を入力すると終了します。
1: play game 2: show match results 3: exit please enter >3 bye.
最後に
gRPCは高速な通信ができるというイメージが先行していましたが、実際に勉強してみるとprotoファイルによるAPI定義とコードの自動生成の方に利点を感じました。
今回はシンプルな単行RPC(Unary RPC)でしたが、次回以降ストリーミング方式のgRPCのサンプルプロジェクトも作成したいと思います。